package jmine.tec.utils.date;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.List;

import javax.persistence.Embeddable;
import javax.persistence.Enumerated;
import javax.persistence.Transient;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.joda.time.DateMidnight;
import org.joda.time.DateTimeConstants;
import org.joda.time.Period;
import org.joda.time.PeriodType;

/**
 * Representa um período em dias/semanas/meses/anos.
 * 
 * @author takeshi
 */
@Embeddable
public class DatePeriod implements Comparable<DatePeriod>, Serializable {
    /**
     * Periodo em dias
     */
    public static final int DAYS = DatePeriodUnit.DAYS.ordinal();

    public static final int DAYS_IN_MONTH = 30;

    public static final int DAYS_IN_WEEK = 7;

    public static final int DAYS_IN_YEAR = 365;

    /**
     * Periodo em meses
     */
    public static final int MONTHS = DatePeriodUnit.MONTHS.ordinal();

    /**
     * Label da unidade do período
     */
    public static final String[] PERIOD_UNIT_LABELS =
            { DatePeriodUnit.DAYS.getLabel(),
            DatePeriodUnit.WEEKS.getLabel(),
            DatePeriodUnit.MONTHS.getLabel(),
            DatePeriodUnit.YEARS.getLabel() };

    /**
     * Código da unidade do período
     */
    public static final String[] PERIOD_UNIT_MESSAGE_CODES = {
            DatePeriodUnit.DAYS.getMessageCode(),
            DatePeriodUnit.WEEKS.getMessageCode(),
            DatePeriodUnit.MONTHS.getMessageCode(),
            DatePeriodUnit.YEARS.getMessageCode() };

    /**
     * Periodo em semanas
     */
    public static final int WEEKS = DatePeriodUnit.WEEKS.ordinal();

    /**
     * Periodo em anos
     */
    public static final int YEARS = DatePeriodUnit.YEARS.ordinal();

    private static final long serialVersionUID = 4691684334952842482L;

    /**
     * Calculates the number of work days between the given days
     * 
     * @param startDate {@link Date}
     * @param finalDate {@link Date}
     * @param holidays {@link List}
     * @return int
     */
    public static int calculateWorkDays(final Date startDate, final Date finalDate, final List<Date> holidays) {

        if (startDate.after(finalDate)) {
            return -calculateWorkDays(finalDate, startDate, holidays);
        }

        Log logger = LogFactory.getLog(DatePeriod.class);
        boolean debug = logger.isDebugEnabled();

        if (debug) {
            logger.debug("work days betweeen: " + startDate + " and " + finalDate + " with holidays: " + holidays);
        }

        DateMidnight dm = new DateMidnight(startDate == null ? new Date() : startDate);
        DateMidnight end = new DateMidnight(finalDate == null ? new Date() : finalDate);
        if (dm.equals(end)) {
            return 0;
        }
        int wd = 0;
        List<Date> list;
        if (holidays == null) {
            list = Collections.emptyList();
        } else {
            list = new ArrayList<Date>(holidays);
        }
        Collections.sort(list);
        while (dm.isBefore(end)) {
            if (isWorkDay(list, dm)) {
                wd++;
            }
            dm = dm.plusDays(1);
        }
        return wd;
    }

    /**
     * @param dm the {@link DateMidnight}
     * @return the {@link Date}
     */
    public static Date dateMidnightToDate(final DateMidnight dm) {
        Calendar cal = Calendar.getInstance();
        cal.set(dm.getYear(), dm.getMonthOfYear() - 1, dm.getDayOfMonth(), 0, 0, 0);
        cal.set(Calendar.MILLISECOND, 0);
        return cal.getTime();
    }

    /**
     * Gets the difference in days of the given dates.
     * 
     * @param startDate {@link Date}
     * @param finalDate {@link Date}
     * @return {@link DatePeriod}
     */
    public static DatePeriod difference(final Date startDate, final Date finalDate) {
        if (startDate.after(finalDate)) {
            return difference(finalDate, startDate).invert();
        }
        final long t0 = startDate.getTime();
        final long t1 = finalDate.getTime();
        Period per = new Period(t0, t1, PeriodType.days());
        return new DatePeriod(false, per.getDays(), DAYS);
    }

    /**
     * Gets the difference in days of the given dates.
     * 
     * @param startDate {@link Date}
     * @param finalDate {@link Date}
     * @param holidays {@link List} of {@link Date}
     * @return {@link DatePeriod}
     */
    public static DatePeriod differenceWorkDays(final Date startDate, final Date finalDate, final List<Date> holidays) {
        Date end = findNextWorkDay(holidays, finalDate);
        int wd = calculateWorkDays(startDate, end, holidays);
        return new DatePeriod(true, wd, DatePeriodUnit.DAYS);
    }

    /**
     * Procura o próximo DU.
     * 
     * @param holidays {@link List} of holidays
     * @param result {@link Date}
     * @return {@link Date}
     */
    public static Date findNextWorkDay(final Collection<? extends Date> holidays, final Date result) {
        DateMidnight dm = new DateMidnight(result);
        while (!isWorkDay(holidays, dm)) {
            dm = dm.plusDays(1);
        }
        return dateMidnightToDate(dm);
    }

    /**
     * Return true if the given {@link DateMidnight} is a workday.
     * 
     * @param holidaysDoPeriodo {@link List}
     * @param dm {@link DateMidnight}
     * @return boolean
     */
    @SuppressWarnings("unchecked")
    private static boolean isWorkDay(final Collection<? extends Date> holidaysDoPeriodo, final DateMidnight dm) {
        if ((dm.getDayOfWeek() == DateTimeConstants.SATURDAY) || (dm.getDayOfWeek() == DateTimeConstants.SUNDAY)) {
            return false;
        }
        Date date = dateMidnightToDate(dm);
        if (holidaysDoPeriodo != null) {
            if (holidaysDoPeriodo instanceof List) {
                if (Collections.binarySearch((List<Date>) holidaysDoPeriodo, date) >= 0) {
                    return false;
                }
            } else if (holidaysDoPeriodo.contains(date)) {
                return false;
            }
        }
        return true;
    }

    private transient Log logger = LogFactory.getLog(this.getClass());

    private boolean debug = this.getLogger().isDebugEnabled();

    private int period;

    private DatePeriodUnit periodUnit;

    private boolean workDays;

    /**
     * Default C'tor
     */
    public DatePeriod() {
    }

    /**
     * C'tor
     * 
     * @param workDays is a work days period?
     * @param days many days
     * @param periodUnit int
     * @deprecated prefer the {@link #DatePeriod(boolean, int, DatePeriodUnit)} instead
     */
    @Deprecated
    public DatePeriod(final boolean workDays, final int days, final int periodUnit) {
        super();
        this.workDays = workDays;
        this.period = days;
        this.periodUnit = DatePeriodUnit.values()[periodUnit];
    }

    /**
     * C'tor
     * 
     * @param workDays boolean
     * @param period int
     * @param periodUnit the {@link DatePeriodUnit}
     */
    public DatePeriod(final boolean workDays, final int period, final DatePeriodUnit periodUnit) {
        super();
        this.workDays = workDays;
        this.period = period;
        this.periodUnit = periodUnit;
    }

    /**
     * @param o {@link DatePeriod}
     * @return int
     */
    public int compareTo(final DatePeriod o) {
        if (this.periodUnit != o.periodUnit) {
            return this.periodUnit.ordinal() - o.periodUnit.ordinal();
        }
        if (this.period != o.period) {
            return this.period - o.period;
        }
        if (this.workDays != o.workDays) {
            return this.workDays ? 1 : -1;
        }
        return 0;
    }

    /**
     * Converts this into days, according to: 1 week -> 7 days 1 month -> 30 days 1 year -> 365 days
     * 
     * @return int
     */
    public int days() {
        switch (this.periodUnit) {
        case DAYS:
            return this.period;
        case WEEKS:
            return DAYS_IN_WEEK * this.period;

        case MONTHS:
            return DAYS_IN_MONTH * this.period;

        case YEARS:
            return DAYS_IN_YEAR * this.period;
        default:
            break;
        }
        this.throwIllegalTimeUnit();
        return 0;
    }

    /**
     * @param obj Object to compare
     * @return boolean
     */
    @Override
    public boolean equals(final Object obj) {
        if (this == obj) {
            return true;
        }
        if (obj == null) {
            return false;
        }
        if (this.getClass() != obj.getClass()) {
            return false;
        }
        final DatePeriod other = (DatePeriod) obj;
        if (this.period != other.period) {
            return false;
        }
        if (this.periodUnit != other.periodUnit) {
            return false;
        }
        if (this.workDays != other.workDays) {
            return false;
        }
        return true;
    }

    /**
     * @return the days
     */
    public int getPeriod() {
        return this.period;
    }

    /**
     * @return the timeUnit
     */
    @Enumerated
    public DatePeriodUnit getPeriodUnit() {
        return this.periodUnit;
    }

    /**
     * @return int the hashCode
     */
    @Override
    public int hashCode() {
        final int prime = 31;
        final int trueHash = 1231;
        final int falseHash = 1237;
        int result = 1;
        result = prime * result + this.period;
        result = prime * result + this.periodUnit.ordinal();
        result = prime * result + (this.workDays ? trueHash : falseHash);
        return result;
    }

    /**
     * Inverts this {@link DatePeriod} object
     * 
     * @return {@link DatePeriod}
     */
    public DatePeriod invert() {
        return new DatePeriod(this.workDays, -this.period, this.periodUnit);
    }

    /**
     * Devolve se o período deve contar apenas dias úteis. Só é contado se a unidade for {@link DatePeriod#DAYS}
     * 
     * @return the workDays
     */
    public boolean isWorkDays() {
        return this.workDays;
    }

    /**
     * Calcula a data final do período, dada a data inicial, levando em consideração os holidays dados <code>null</code> se a unidade for
     * dias.
     * 
     * @param startDate {@link Date} a data inicial
     * @param holidays List a localidade a ser utilizada no cálculo
     * @return {@link Date}
     */
    public Date plusDate(final Date startDate, final Collection<? extends Date> holidays) {
        Date result;

        if (this.period == 0) {
            result = new Date(startDate.getTime());
        } else {
            result = this.findAbsoluteDate(startDate, holidays);
        }
        if (this.isWorkDays()) {
            result = findNextWorkDay(holidays, result);
        }
        if (this.debug) {
            this.getLogger().debug("plusDate: " + startDate + " + " + this + " with holidays: " + holidays + " returning: " + result);
        }
        return result;
    }

    /**
     * Sums this {@link DatePeriod} with another {@link DatePeriod}, starting from the given date and considering the list of holidays.
     * 
     * @param other the other {@link DatePeriod}
     * @param from the {@link Date} to start from
     * @param holidays the {@link List}
     * @return {@link DatePeriod}
     */
    public DatePeriod plusDatePeriod(final DatePeriod other, final Date from, final List<Date> holidays) {
        if (this.periodUnit == other.periodUnit) {
            if (this.workDays == other.workDays) {
                return new DatePeriod(this.workDays, this.period + other.period, this.periodUnit);
            }
        }
        Date toUse = from == null ? new Date() : from;

        DatePeriod self = this.toAbsoluteDays(toUse, holidays);
        DatePeriod to = other.toAbsoluteDays(toUse, holidays);

        if (this.debug) {
            this.getLogger().debug(self + " + " + to + " starting from: " + from + " with holidays: " + holidays);
        }

        return self.plusDatePeriod(to, from, holidays);
    }

    /**
     * Transforms this {@link DatePeriod} into absolute days period.
     * 
     * @param startDate {@link Date}
     * @param holidays {@link List}
     * @return {@link DatePeriod}
     */
    public DatePeriod toAbsoluteDays(final Date startDate, final Collection<? extends Date> holidays) {
        Date date = this.plusDate(startDate, holidays);
        final long t0 = startDate.getTime();
        final long t1 = date.getTime();
        Period per = new Period(t0, t1, PeriodType.days());
        return new DatePeriod(false, per.getDays(), DAYS);
    }

    /**
     * @return {@link String} representation of this instance
     */
    @Override
    public String toString() {
        String s = (this.period > 1 || this.period < -1) ? "s" : "";
        return "DatePeriod: " + this.period + " " + this.periodUnit.getLabel() + s + (this.workDays ? " (work days only)" : "");
    }

    /**
     * @param start the start date
     * @param holidays {@link List} of holidays
     * @return {@link DatePeriod}
     */
    public DatePeriod toWorkDays(final Date start, final Collection<? extends Date> holidays) {
        if (this.isWorkDays()) {
            return this;
        }
        Date result = this.findAbsoluteDate(start, holidays);
        int wd = 0;
        DateMidnight dm = new DateMidnight(start);
        while (dm.isBefore(result.getTime())) {
            if (isWorkDay(holidays, dm)) {
                wd++;
            }
            dm = dm.plusDays(1);
        }
        return new DatePeriod(true, wd, DAYS);
    }

    /**
     * Finds the absolute date that this {@link DatePeriod} represents when it expires.
     * 
     * @param startDate {@link Date}
     * @param holidays {@link List}
     * @return {@link Date}
     */
    private Date findAbsoluteDate(final Date startDate, final Collection<? extends Date> holidays) {
        Date result;

        switch (this.periodUnit) {
        case DAYS:
            result = this.plusDateDays(startDate, holidays);
            break;
        case WEEKS:
            result = this.plusDateWeeks(startDate);
            break;
        case MONTHS:
            result = this.plusDateMonths(startDate);
            break;
        case YEARS:
            result = this.plusDateYears(startDate);
            break;
        default:
            this.throwIllegalTimeUnit();
            result = null;
            break;
        }
        return result;
    }

    /**
     * Calcula o dia final do período.
     * 
     * @param startDate {@link Date}
     * @param holidays {@link List}
     * @return {@link Date}
     */
    private Date plusDateDays(final Date startDate, final Collection<? extends Date> holidays) {
        if (!this.workDays) {
            return this.plusDays(startDate);
        }
        List<Date> localHolidays;
        if (holidays == null) {
            localHolidays = Collections.emptyList();
        } else {
            localHolidays = new ArrayList<Date>(holidays);
        }
        Collections.sort(localHolidays);
        return this.plusWorkDays(startDate, localHolidays);
    }

    /**
     * @param startDate {@link Date}
     * @return {@link Date}
     */
    private Date plusDateMonths(final Date startDate) {
        final DateMidnight dm = new DateMidnight(startDate).plusMonths(this.period);
        return dateMidnightToDate(dm);
    }

    /**
     * @param startDate {@link Date}
     * @return {@link Date}
     */
    private Date plusDateWeeks(final Date startDate) {
        final DateMidnight dm = new DateMidnight(startDate).plusWeeks(this.period);
        return dateMidnightToDate(dm);
    }

    /**
     * @param startDate {@link Date}
     * @return {@link Date}
     */
    private Date plusDateYears(final Date startDate) {
        DateMidnight dm = new DateMidnight(startDate);
        dm = dm.plusYears(this.period);
        return dateMidnightToDate(dm);
    }

    /**
     * @param startDate {@link Date}
     * @return {@link Date}
     */
    private Date plusDays(final Date startDate) {
        DateMidnight dm = new DateMidnight(startDate);
        dm = dm.plusDays(this.period);
        return dateMidnightToDate(dm);
    }

    /**
     * Calcula o dia final do período expresso em DU.
     * 
     * @param startDate {@link Date}
     * @param holidays {@link Localidade}
     * @return {@link Date}
     */
    private Date plusWorkDays(final Date startDate, final List<Date> holidays) {
        int index = Collections.binarySearch(holidays, startDate);
        if (index < 0) {
            index = -index - 1;
        }
        final List<Date> holidaysDoPeriodo;
        if (this.period > 0) {
            holidaysDoPeriodo = holidays.subList(index, holidays.size());
        } else {
            holidaysDoPeriodo = holidays.subList(0, index);
        }
        DateMidnight dm = new DateMidnight(startDate);

        int inc = this.period > 0 ? 1 : -1;
        int iterations = Math.abs(this.period);
        while (!isWorkDay(holidaysDoPeriodo, dm)) {
            dm = dm.plusDays(inc);
        }

        for (int i = 0; i < iterations;) {
            dm = dm.plusDays(inc);
            boolean workDay = isWorkDay(holidaysDoPeriodo, dm);
            if (workDay) {
                i++;
            }
        }
        return dateMidnightToDate(dm);
    }

    /**
     */
    private void throwIllegalTimeUnit() {
        throw new IllegalArgumentException("TimeUnit unknown: " + this.periodUnit);
    }

    /**
     * @param days the days to set
     */
    protected void setPeriod(final int days) {
        this.period = days;
    }

    /**
     * @param timeUnit the timeUnit to set
     */
    protected void setPeriodUnit(final DatePeriodUnit timeUnit) {
        this.periodUnit = timeUnit;
    }

    /**
     * @param workDays the workDays to set
     */
    protected void setWorkDays(final boolean workDays) {
        this.workDays = workDays;
    }

    /**
     * @return the logger
     */
    @Transient
    private Log getLogger() {
        if (this.logger == null) {
            this.logger = LogFactory.getLog(this.getClass());
        }
        return this.logger;
    }

}
